Skip to content

feat(feedback): implement shake gesture detection#5150

Open
antonis wants to merge 31 commits intomainfrom
antonis/feedback-shake
Open

feat(feedback): implement shake gesture detection#5150
antonis wants to merge 31 commits intomainfrom
antonis/feedback-shake

Conversation

@antonis
Copy link
Contributor

@antonis antonis commented Mar 3, 2026

📜 Description

Implements shake gesture detection for the user feedback form. When useShakeGesture is enabled in SentryFeedbackOptions, shaking the device opens the feedback form.

The implementation uses the device's accelerometer (via SensorManager) with a 2.7G threshold and 1-second cooldown. A new ShakeDetectionIntegration registers lifecycle callbacks to start/stop the detector when the activity resumes/pauses.

Note: this is exposed to be used on React Native too getsentry/sentry-react-native#5754

💡 Motivation and Context

The useShakeGesture property already existed in SentryFeedbackOptions but was never wired up. This PR implements the feature. The implementation is placed here (sentry-java) rather than in each SDK separately so it can be reused by all SDKs that embed the feedback UI (React Native, Flutter, .NET MAUI, Unity).

No special permissions are required — TYPE_ACCELEROMETER is not a protected sensor. Only BODY_SENSORS requires a permission.

💚 How did you test it?

  • Tested in a sample Android app (see 0cb8d00) — shaking the device opens the feedback form
  • Unit tests added for SentryShakeDetector and ShakeDetectionIntegration

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed. docs(android): Add shake-to-report feedback documentation sentry-docs#16792
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

sentry-react-native will remove its own RNSentryShakeDetector and delegate to this class instead.

Adds SentryShakeDetector (accelerometer-based) and ShakeDetectionIntegration
that shows the feedback dialog when a shake is detected. Controlled by
SentryFeedbackOptions.useShakeGesture (default false).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (feedback) Implement shake gesture detection by antonis in #5150

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against a4b5a97

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 331.70 ms 407.46 ms 75.75 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
ee747ae 357.79 ms 421.84 ms 64.05 ms
22f4345 307.87 ms 354.51 ms 46.64 ms
dc4cc7a 361.10 ms 439.53 ms 78.43 ms
a416a65 316.52 ms 359.67 ms 43.15 ms
d15471f 310.66 ms 368.19 ms 57.53 ms
9fbb112 361.43 ms 427.57 ms 66.14 ms
dba088c 328.51 ms 423.79 ms 95.28 ms
27d7cf8 397.90 ms 498.65 ms 100.75 ms
951caf7 323.66 ms 392.82 ms 69.16 ms
a416a65 295.53 ms 373.74 ms 78.21 ms

App size

Revision Plain With Sentry Diff
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
22f4345 1.58 MiB 2.29 MiB 719.83 KiB
dc4cc7a 1.58 MiB 2.19 MiB 619.28 KiB
a416a65 1.58 MiB 2.12 MiB 555.26 KiB
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
dba088c 1.58 MiB 2.13 MiB 558.99 KiB
27d7cf8 1.58 MiB 2.12 MiB 549.42 KiB
951caf7 1.58 MiB 2.13 MiB 558.77 KiB
a416a65 1.58 MiB 2.12 MiB 555.26 KiB

Previous results on branch: antonis/feedback-shake

Startup times

Revision Plain With Sentry Diff
2052d5f 331.52 ms 401.71 ms 70.19 ms
d743634 404.69 ms 507.35 ms 102.66 ms
395b61e 318.16 ms 373.86 ms 55.70 ms
ba57364 317.45 ms 360.35 ms 42.90 ms
3a14925 327.15 ms 380.52 ms 53.37 ms
90c01e9 314.38 ms 370.51 ms 56.13 ms
58a9026 330.31 ms 370.45 ms 40.13 ms
a1a1e57 323.92 ms 367.22 ms 43.31 ms

App size

Revision Plain With Sentry Diff
2052d5f 1.58 MiB 2.29 MiB 726.85 KiB
d743634 0 B 0 B 0 B
395b61e 1.58 MiB 2.29 MiB 727.32 KiB
ba57364 1.58 MiB 2.29 MiB 727.15 KiB
3a14925 1.58 MiB 2.29 MiB 727.39 KiB
90c01e9 1.58 MiB 2.29 MiB 727.37 KiB
58a9026 1.58 MiB 2.29 MiB 727.33 KiB
a1a1e57 1.58 MiB 2.29 MiB 727.28 KiB

antonis and others added 2 commits March 4, 2026 13:08
- Add volatile/AtomicLong for thread-safe cross-thread field access
- Use SystemClock.elapsedRealtime() instead of System.currentTimeMillis()
- Use SENSOR_DELAY_NORMAL for better battery efficiency
- Add multi-shake counting (2+ threshold crossings within 1.5s window)
- Handle deferred init for already-resumed activities
- Wrap showDialog() in try-catch to prevent app crashes
- Improve activity transition handling in onActivityPaused
- Mark SentryShakeDetector as @ApiStatus.Internal
- Add unit tests for SentryShakeDetector and ShakeDetectionIntegration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@antonis antonis marked this pull request as ready for review March 4, 2026 13:12
@antonis antonis requested a review from alwx March 4, 2026 13:14
… shakes

Track dialog visibility with an isDialogShowing flag that is set before
showing and cleared via the onFormClose callback when the dialog is
dismissed. Double-checked on both sensor and UI threads to avoid races.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… growth

Save the user's original onFormClose once during register() and restore
it after each dialog dismiss, instead of wrapping it with a new lambda
each time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ck flag

If showDialog silently fails (e.g. activity destroyed between post and
execution), isDialogShowing would stay true forever, permanently
disabling shake-to-feedback. Reset it in onActivityPaused since the
dialog cannot outlive its host activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ActivityDestroyed

AlertDialog survives pause/resume cycles (e.g. screen off/on), so
resetting isDialogShowing in onActivityPaused allowed duplicate dialogs.
Move the reset to onActivityDestroyed where the dialog truly cannot
survive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…back on error

- Only reset isDialogShowing in onActivityDestroyed when it's the
  activity that hosts the dialog, not any unrelated activity.
- Restore originalOnFormClose in the catch block when showDialog throws.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the end goal is to add this to improve user feedback, but the way it is, it's doing nothing related to user feedback.
Might be a good idea to rename this to FeedbackShakeIntegration so the name of it self explains the usage of it, instead of leaving it generic, what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with 1fd4f5b

(Application) context, buildInfoProvider, activityFramesTracker));
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
options.addIntegration(new ShakeDetectionIntegration((Application) context));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: is it possible to only add when useShakeGesture is set to true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the time installDefaultIntegrations runs in AndroidOptionsInitializer, the user's configuration callback hasn't executed yet and useShakeGesture is still false. That's why register() checks the option later. This is the same pattern used by other integrations (e.g., UserInteractionIntegration is always added but checks its options in register())

sensorManager.unregisterListener(this);
sensorManager = null;
}
listener = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: why not set listener null first?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Updated with 1fd4f5b

Co-authored-by: LucasZF <lucas-zimerman1@hotmail.com>
@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Name App ID Version Configuration Install Page
SDK Size io.sentry.tests.size 8.35.0 (1) release Install Build

Allow enabling shake gesture via AndroidManifest.xml:
<meta-data android:name="io.sentry.feedback.use-shake-gesture" android:value="true" />

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

antonis and others added 3 commits March 9, 2026 16:06
…og is showing

onActivityPaused always fires before onActivityDestroyed. Without this
fix, currentActivity was set to null in onPause, making the cleanup
condition in onActivityDestroyed (activity == currentActivity) always
false. This left isDialogShowing permanently stuck as true, disabling
shake-to-feedback for the rest of the session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The detector was constructed with NoOpLogger and the logger field was
final, so all diagnostic messages (sensor unavailable warnings) were
silently swallowed. Now init(context, logger) updates the logger from
SentryOptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

@antonis
Copy link
Contributor Author

antonis commented Mar 9, 2026

I think there's a couple of fundamental changes we need to make here so not approving yet, but looks great otherwise!

Thank you for the feedback @romtsn 🙇

Btw, would be great if we can also add the new option to ManifestMetadataReader and update the sentry-docs PR and the changelog entry to mention it as well

Good idea 👍 Added with 65122ca
Also updated the doc with getsentry/sentry-docs@02d081a

@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

@antonis antonis marked this pull request as ready for review March 9, 2026 15:15
- Clear currentActivity in onActivityDestroyed to prevent holding a
  stale reference to a destroyed activity context
- Reset shakeCount and firstShakeTimestamp in stop() to prevent
  cross-session false triggers across pause/resume cycles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

When a dialog is showing on Activity A and the user navigates to
Activity B (e.g. via notification), onActivityResumed(B) overwrites
currentActivity. Later onActivityDestroyed(A) can't match and cleanup
never runs, leaving isDialogShowing permanently stuck. Now we detect
this in onActivityResumed and clean up proactively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

The onFormClose lambda was reading previousOnFormClose field at dismiss
time. If onActivityResumed or onActivityDestroyed already restored and
nulled the field, the lambda would overwrite onFormClose with null. Now
captured as a local variable at dialog creation time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

When close() is called while a dialog is showing, lifecycle callbacks
are unregistered so onActivityDestroyed cleanup won't fire. Restore
previousOnFormClose and reset dialog state in close() to prevent
the callback from being permanently overwritten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

handlerThread = new HandlerThread("sentry-shake");
handlerThread.start();
final Handler handler = new Handler(handlerThread.getLooper());
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling start() twice leaks previous HandlerThread

Medium Severity

SentryShakeDetector.start() overwrites the handlerThread field with a new HandlerThread without stopping/quitting the previous one. If start() is called twice without an intervening stop(), the old HandlerThread leaks and continues running. While FeedbackShakeIntegration always calls stop() before start(), this class is public and explicitly designed for reuse by React Native and other hybrid SDKs (per the PR description). External consumers calling start() on an already-started detector will silently leak threads.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of previous finding (see this reply). The class is @ApiStatus.Internal and all call sites in FeedbackShakeIntegration call stopShakeDetection() before startShakeDetection() (which itself calls stop() first at line 131). No thread leak is possible.

@sentry
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

@antonis antonis requested a review from romtsn March 9, 2026 16:41
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

}
previousOnFormClose = null;
});
new SentryUserFeedbackDialog.Builder(active).create().show();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing activity validity check before showing dialog

Low Severity

The shake callback captures currentActivity on the background handler thread and posts to runOnUiThread without first checking isFinishing() or isDestroyed() on the activity. Because onActivityPaused/onActivityDestroyed run on the main thread, there's a race window where the activity becomes invalid between capturing active on the handler thread and the runnable executing on the UI thread. This results in a WindowManager.BadTokenException when show() is called. While the try/catch(Throwable) prevents a crash, the codebase's established pattern in ScreenshotUtils.isActivityValid() proactively checks !activity.isFinishing() && !activity.isDestroyed() to avoid this. An isFinishing()/isDestroyed() guard inside the runOnUiThread lambda (before SentryUserFeedbackDialog.Builder) would prevent unnecessary error-level logging.

Fix in Cursor Fix in Web

}
currentActivity = activity;
startShakeDetection(activity);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent dialog wrappers corrupt onFormClose callback state

Low Severity

When onActivityResumed fires for a new activity while a dialog is showing on the old one, it resets isDialogShowing to false and restores onFormClose to previousOnFormClose. If the user then shakes on the new activity, a second dialog is created with a fresh onFormClose wrapper. When the old dialog (on the old activity) is eventually dismissed, its captured wrapper overwrites the global onFormClose with the original callback, destroying the new dialog's wrapper. This means the new dialog's dismiss won't reset isDialogShowing, potentially leaving the integration in a stuck state where no further shake dialogs can be shown.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants